/*
 * #%L
 * Alfresco Repository
 * %%
 * Copyright (C) 2005 - 2016 Alfresco Software Limited
 * %%
 * This file is part of the Alfresco software. 
 * If the software was purchased under a paid Alfresco license, the terms of 
 * the paid license agreement will prevail.  Otherwise, the software is 
 * provided under the following open source license terms:
 * 
 * Alfresco is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Lesser General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 * 
 * Alfresco is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU Lesser General Public License for more details.
 * 
 * You should have received a copy of the GNU Lesser General Public License
 * along with Alfresco. If not, see <http://www.gnu.org/licenses/>.
 * #L%
 */
package org.alfresco.repo.security.sync.ldap;

import java.text.DateFormat;
import java.text.MessageFormat;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.AbstractCollection;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.TimeZone;
import java.util.TreeMap;
import java.util.TreeSet;
import java.util.regex.Pattern;
import javax.naming.CommunicationException;
import javax.naming.CompositeName;
import javax.naming.Context;
import javax.naming.InvalidNameException;
import javax.naming.Name;
import javax.naming.NamingEnumeration;
import javax.naming.NamingException;
import javax.naming.ServiceUnavailableException;
import javax.naming.directory.Attribute;
import javax.naming.directory.Attributes;
import javax.naming.directory.DirContext;
import javax.naming.directory.InitialDirContext;
import javax.naming.directory.SearchControls;
import javax.naming.directory.SearchResult;
import javax.naming.ldap.Control;
import javax.naming.ldap.LdapContext;
import javax.naming.ldap.LdapName;
import javax.naming.ldap.PagedResultsControl;
import javax.naming.ldap.PagedResultsResponseControl;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.beans.factory.InitializingBean;

import org.alfresco.error.AlfrescoRuntimeException;
import org.alfresco.model.ContentModel;
import org.alfresco.repo.management.subsystems.ActivateableBean;
import org.alfresco.repo.security.authentication.AuthenticationDiagnostic;
import org.alfresco.repo.security.authentication.AuthenticationException;
import org.alfresco.repo.security.authentication.ldap.LDAPInitialDirContextFactory;
import org.alfresco.repo.security.sync.NodeDescription;
import org.alfresco.repo.security.sync.UserRegistry;
import org.alfresco.service.namespace.NamespaceService;
import org.alfresco.service.namespace.QName;
import org.alfresco.util.Pair;
import org.alfresco.util.PropertyMap;

/**
 * A {@link UserRegistry} implementation with the ability to query Alfresco-like descriptions of users and groups from an LDAP directory, optionally restricted to those modified since a certain time.
 * 
 * @author dward
 */
public class LDAPUserRegistry implements UserRegistry, LDAPNameResolver, InitializingBean, ActivateableBean
{

    /** The logger. */
    private static Log logger = LogFactory.getLog(LDAPUserRegistry.class);

    /** The regular expression that will match the attribute at the end of a range. */
    private static final Pattern PATTERN_RANGE_END = Pattern.compile(";range=[0-9]+-\\*");

    public static final String NAMING_TIMEOUT_EXCEPTION_MESSAGE = "LDAP response read timed out";

    /** Is this bean active? I.e. should this part of the subsystem be used? */
    private boolean active = true;

    /** Enable progress estimation? When enabled, the user query has to be run twice in order to count entries. */
    private boolean enableProgressEstimation = true;

    /** The group query. */
    private String groupQuery = "(objectclass=groupOfNames)";

    /** The group differential query. */
    private String groupDifferentialQuery = "(&(objectclass=groupOfNames)(!(modifyTimestamp<={0})))";

    /** The person query. */
    private String personQuery = "(objectclass=inetOrgPerson)";

    /** The person differential query. */
    private String personDifferentialQuery = "(&(objectclass=inetOrgPerson)(!(modifyTimestamp<={0})))";

    /** The group search base. */
    private String groupSearchBase;

    /** The user search base. */
    private String userSearchBase;

    /** The group id attribute name. */
    private String groupIdAttributeName = "cn";

    /** The user id attribute name. */
    private String userIdAttributeName = "uid";

    /** The member attribute name. */
    private String memberAttributeName = "member";

    /** The modification timestamp attribute name. */
    private String modifyTimestampAttributeName = "modifyTimestamp";

    /** The group type. */
    private String groupType = "groupOfNames";

    /** The person type. */
    private String personType = "inetOrgPerson";

    /** The ldap initial context factory. */
    private LDAPInitialDirContextFactory ldapInitialContextFactory;

    /** The namespace service. */
    private NamespaceService namespaceService;

    /** The person attribute mapping. */
    private Map<String, String> personAttributeMapping;

    /** The person attribute defaults. */
    private Map<String, String> personAttributeDefaults = Collections.emptyMap();

    /** The group attribute mapping. */
    private Map<String, String> groupAttributeMapping;

    /** The group attribute defaults. */
    private Map<String, String> groupAttributeDefaults = Collections.emptyMap();

    /**
     * The query batch size. If positive, indicates that RFC 2696 paged results should be used to split query results into batches of the specified size. Overcomes any size limits imposed by the LDAP server.
     */
    private int queryBatchSize;

    /**
     * The attribute retrieval batch size. If positive, indicates that range retrieval should be used to fetch multi-valued attributes (such as member) in batches of the specified size. Overcomes any size limits imposed by the LDAP server.
     */
    private int attributeBatchSize;

    /** Should we error on missing group members?. */
    private boolean errorOnMissingMembers;

    /** Should we error on duplicate group IDs?. */
    private boolean errorOnDuplicateGID;

    /** Should we error on missing group IDs?. */
    private boolean errorOnMissingGID = false;

    /** Should we error on missing user IDs?. */
    private boolean errorOnMissingUID = false;

    /** An array of all LDAP attributes to be queried from users plus a set of property QNames. */
    private Pair<String[], Set<QName>> userKeys;

    /** An array of all LDAP attributes to be queried from groups plus a set of property QNames. */
    private Pair<String[], Set<QName>> groupKeys;

    /** The LDAP generalized time format. */
    private DateFormat timestampFormat;

    /** The LDAP User Account Status Property Interpreter */
    private AbstractDirectoryServiceUserAccountStatusInterpreter userAccountStatusInterpreter;

    /**
     * Instantiates a new lDAP user registry.
     */
    public LDAPUserRegistry()
    {
        // Default to official LDAP generalized time format (unfortunately not used by Active Directory)
        setTimestampFormat("yyyyMMddHHmmss'Z'");
    }

    /**
     * Controls whether this bean is active. I.e. should this part of the subsystem be used?
     * 
     * @param active
     *            <code>true</code> if this bean is active
     */
    public void setActive(boolean active)
    {
        this.active = active;
    }

    /**
     * Controls whether progress estimation is enabled. When enabled, the user query has to be run twice in order to count entries.
     * 
     * @param enableProgressEstimation
     *            <code>true</code> if progress estimation is enabled
     */
    public void setEnableProgressEstimation(boolean enableProgressEstimation)
    {
        this.enableProgressEstimation = enableProgressEstimation;
    }

    /**
     * Sets the group id attribute name.
     * 
     * @param groupIdAttributeName
     *            the group id attribute name
     */
    public void setGroupIdAttributeName(String groupIdAttributeName)
    {
        this.groupIdAttributeName = groupIdAttributeName;
    }

    /**
     * Sets the group query.
     * 
     * @param groupQuery
     *            the group query
     */
    public void setGroupQuery(String groupQuery)
    {
        this.groupQuery = groupQuery;
    }

    /**
     * Sets the group differential query.
     * 
     * @param groupDifferentialQuery
     *            the group differential query
     */
    public void setGroupDifferentialQuery(String groupDifferentialQuery)
    {
        this.groupDifferentialQuery = groupDifferentialQuery;
    }

    /**
     * Sets the person query.
     * 
     * @param personQuery
     *            the person query
     */
    public void setPersonQuery(String personQuery)
    {
        this.personQuery = personQuery;
    }

    /**
     * Sets the person differential query.
     * 
     * @param personDifferentialQuery
     *            the person differential query
     */
    public void setPersonDifferentialQuery(String personDifferentialQuery)
    {
        this.personDifferentialQuery = personDifferentialQuery;
    }

    /**
     * Sets the group type.
     * 
     * @param groupType
     *            the group type
     */
    public void setGroupType(String groupType)
    {
        this.groupType = groupType;
    }

    /**
     * Sets the member attribute name.
     * 
     * @param memberAttribute
     *            the member attribute name
     */
    public void setMemberAttribute(String memberAttribute)
    {
        this.memberAttributeName = memberAttribute;
    }

    /**
     * Sets the person type.
     * 
     * @param personType
     *            the person type
     */
    public void setPersonType(String personType)
    {
        this.personType = personType;
    }

    /**
     * Sets the group search base.
     * 
     * @param groupSearchBase
     *            the group search base
     */
    public void setGroupSearchBase(String groupSearchBase)
    {
        this.groupSearchBase = groupSearchBase;
    }

    /**
     * Sets the user search base.
     * 
     * @param userSearchBase
     *            the user search base
     */
    public void setUserSearchBase(String userSearchBase)
    {
        this.userSearchBase = userSearchBase;
    }

    /**
     * Sets the user id attribute name.
     * 
     * @param userIdAttributeName
     *            the user id attribute name
     */
    public void setUserIdAttributeName(String userIdAttributeName)
    {
        this.userIdAttributeName = userIdAttributeName;
    }

    /**
     * Sets the modification timestamp attribute name.
     * 
     * @param modifyTimestampAttributeName
     *            the modification timestamp attribute name
     */
    public void setModifyTimestampAttributeName(String modifyTimestampAttributeName)
    {
        this.modifyTimestampAttributeName = modifyTimestampAttributeName;
    }

    /**
     * Sets the timestamp format. Unfortunately, this varies between directory servers.
     * 
     * @param timestampFormat
     *            the timestamp format
     *            <ul>
     *            <li>OpenLDAP: "yyyyMMddHHmmss'Z'"
     *            <li>Active Directory: "yyyyMMddHHmmss'.0Z'"
     *            </ul>
     */
    public void setTimestampFormat(String timestampFormat)
    {
        this.timestampFormat = new SimpleDateFormat(timestampFormat, Locale.UK);
        this.timestampFormat.setTimeZone(TimeZone.getTimeZone("GMT"));
    }

    /**
     * Decides whether to error on missing group members.
     * 
     * @param errorOnMissingMembers
     *            <code>true</code> if we should error on missing group members
     */
    public void setErrorOnMissingMembers(boolean errorOnMissingMembers)
    {
        this.errorOnMissingMembers = errorOnMissingMembers;
    }

    /**
     * Decides whether to error on missing group IDs.
     * 
     * @param errorOnMissingGID
     *            <code>true</code> if we should error on missing group IDs
     */
    public void setErrorOnMissingGID(boolean errorOnMissingGID)
    {
        this.errorOnMissingGID = errorOnMissingGID;
    }

    /**
     * Decides whether to error on missing user IDs.
     * 
     * @param errorOnMissingUID
     *            <code>true</code> if we should error on missing user IDs
     */
    public void setErrorOnMissingUID(boolean errorOnMissingUID)
    {
        this.errorOnMissingUID = errorOnMissingUID;
    }

    /**
     * Decides whether to error on duplicate group IDs.
     * 
     * @param errorOnDuplicateGID
     *            <code>true</code> if we should error on duplicate group IDs
     */
    public void setErrorOnDuplicateGID(boolean errorOnDuplicateGID)
    {
        this.errorOnDuplicateGID = errorOnDuplicateGID;
    }

    /**
     * Sets the LDAP initial dir context factory.
     * 
     * @param ldapInitialDirContextFactory
     *            the new LDAP initial dir context factory
     */
    public void setLDAPInitialDirContextFactory(LDAPInitialDirContextFactory ldapInitialDirContextFactory)
    {
        this.ldapInitialContextFactory = ldapInitialDirContextFactory;
    }

    /**
     * Sets the namespace service.
     * 
     * @param namespaceService
     *            the namespace service
     */
    public void setNamespaceService(NamespaceService namespaceService)
    {
        this.namespaceService = namespaceService;
    }

    /**
     * Sets the person attribute defaults.
     * 
     * @param personAttributeDefaults
     *            the person attribute defaults
     */
    public void setPersonAttributeDefaults(Map<String, String> personAttributeDefaults)
    {
        this.personAttributeDefaults = personAttributeDefaults;
    }

    /**
     * Sets the person attribute mapping.
     * 
     * @param personAttributeMapping
     *            the person attribute mapping
     */
    public void setPersonAttributeMapping(Map<String, String> personAttributeMapping)
    {
        this.personAttributeMapping = personAttributeMapping;
    }

    /**
     * Sets the group attribute defaults.
     * 
     * @param groupAttributeDefaults
     *            the group attribute defaults
     */
    public void setGroupAttributeDefaults(Map<String, String> groupAttributeDefaults)
    {
        this.groupAttributeDefaults = groupAttributeDefaults;
    }

    /**
     * Sets the group attribute mapping.
     * 
     * @param groupAttributeMapping
     *            the group attribute mapping
     */
    public void setGroupAttributeMapping(Map<String, String> groupAttributeMapping)
    {
        this.groupAttributeMapping = groupAttributeMapping;
    }

    /**
     * Sets the query batch size.
     * 
     * @param queryBatchSize
     *            If positive, indicates that RFC 2696 paged results should be used to split query results into batches of the specified size. Overcomes any size limits imposed by the LDAP server.
     */
    public void setQueryBatchSize(int queryBatchSize)
    {
        this.queryBatchSize = queryBatchSize;
    }

    /**
     * Sets the attribute batch size.
     * 
     * @param attributeBatchSize
     *            If positive, indicates that range retrieval should be used to fetch multi-valued attributes (such as member) in batches of the specified size. Overcomes any size limits imposed by the LDAP server.
     */
    public void setAttributeBatchSize(int attributeBatchSize)
    {
        this.attributeBatchSize = attributeBatchSize;
    }

    public void setUserAccountStatusInterpreter(AbstractDirectoryServiceUserAccountStatusInterpreter userAccountStatusInterpreter)
    {
        this.userAccountStatusInterpreter = userAccountStatusInterpreter;
    }

    public AbstractDirectoryServiceUserAccountStatusInterpreter getUserAccountStatusInterpreter()
    {
        return userAccountStatusInterpreter;
    }

    /* (non-Javadoc)
     * 
     * @see org.alfresco.repo.management.subsystems.ActivateableBean#isActive() */
    public boolean isActive()
    {
        return this.active;
    }

    /* (non-Javadoc)
     * 
     * @see org.springframework.beans.factory.InitializingBean#afterPropertiesSet() */
    public void afterPropertiesSet() throws Exception
    {
        if (this.personAttributeMapping == null)
        {
            this.personAttributeMapping = new HashMap<String, String>(5);
        }
        this.personAttributeMapping.put(ContentModel.PROP_USERNAME.toPrefixString(this.namespaceService),
                this.userIdAttributeName);
        this.userKeys = initKeys(this.personAttributeMapping);

        // Include a range restriction for the multi-valued member attribute if this is enabled
        if (this.groupAttributeMapping == null)
        {
            this.groupAttributeMapping = new HashMap<String, String>(5);
        }
        this.groupAttributeMapping.put(ContentModel.PROP_AUTHORITY_NAME.toPrefixString(this.namespaceService),
                this.groupIdAttributeName);
        this.groupKeys = initKeys(this.groupAttributeMapping,
                this.attributeBatchSize > 0 ? this.memberAttributeName + ";range=0-" + (this.attributeBatchSize - 1)
                        : this.memberAttributeName);
    }

    /* (non-Javadoc)
     * 
     * @see org.alfresco.repo.security.sync.UserRegistry#getPersonMappedProperties() */
    public Set<QName> getPersonMappedProperties()
    {
        return this.userKeys.getSecond();
    }

    /* (non-Javadoc)
     * 
     * @see org.alfresco.repo.security.sync.UserRegistry#getPersons(java.util.Date) */
    public Collection<NodeDescription> getPersons(Date modifiedSince)
    {
        return new PersonCollection(modifiedSince);
    }

    /* (non-Javadoc)
     * 
     * @see org.alfresco.repo.security.sync.UserRegistry#getPersonNames() */
    public Collection<String> getPersonNames()
    {
        final List<String> personNames = new LinkedList<String>();
        processQuery(new AbstractSearchCallback() {
            protected void doProcess(SearchResult result) throws NamingException, ParseException
            {
                Attribute nameAttribute = result.getAttributes().get(LDAPUserRegistry.this.userIdAttributeName);
                if (nameAttribute == null)
                {
                    if (LDAPUserRegistry.this.errorOnMissingUID)
                    {
                        Object[] params = {result.getNameInNamespace(), LDAPUserRegistry.this.userIdAttributeName};
                        throw new AlfrescoRuntimeException("synchronization.err.ldap.get.user.id.missing", params);
                    }
                    else
                    {
                        LDAPUserRegistry.logger.warn("User missing user id attribute DN ="
                                + result.getNameInNamespace() + "  att = " + LDAPUserRegistry.this.userIdAttributeName);
                    }
                }
                else
                {
                    if (LDAPUserRegistry.logger.isDebugEnabled())
                    {
                        LDAPUserRegistry.logger.debug("Person DN recognized: " + nameAttribute.get());
                    }
                    personNames.add((String) nameAttribute.get());
                }
            }

            public void close() throws NamingException
            {}

        }, this.userSearchBase, this.personQuery, new String[]{
                this.userIdAttributeName
        });
        return personNames;
    }

    /* (non-Javadoc)
     * 
     * @see org.alfresco.repo.security.sync.UserRegistry#getGroupNames() */
    public Collection<String> getGroupNames()
    {
        final List<String> groupNames = new LinkedList<String>();
        processQuery(new AbstractSearchCallback() {

            protected void doProcess(SearchResult result) throws NamingException, ParseException
            {
                Attribute nameAttribute = result.getAttributes().get(LDAPUserRegistry.this.groupIdAttributeName);
                if (nameAttribute == null)
                {
                    if (LDAPUserRegistry.this.errorOnMissingGID)
                    {
                        Object[] params = {result.getNameInNamespace(), LDAPUserRegistry.this.groupIdAttributeName};
                        throw new AlfrescoRuntimeException("synchronization.err.ldap.get.group.id.missing", params);
                    }
                    else
                    {
                        LDAPUserRegistry.logger.warn("Missing GID on " + result.getNameInNamespace());
                    }
                }
                else
                {
                    String authority = "GROUP_" + (String) nameAttribute.get();
                    if (LDAPUserRegistry.logger.isDebugEnabled())
                    {
                        LDAPUserRegistry.logger.debug("Group DN recognized: " + authority);
                    }
                    groupNames.add(authority);
                }
            }

            public void close() throws NamingException
            {}

        }, this.groupSearchBase, this.groupQuery, new String[]{
                this.groupIdAttributeName
        });
        return groupNames;
    }

    /* (non-Javadoc)
     * 
     * @see org.alfresco.repo.security.sync.UserRegistry#getGroups(java.util.Date) */
    public Collection<NodeDescription> getGroups(Date modifiedSince)
    {
        // Work out whether the user and group trees are disjoint. This may allow us to optimize reverse DN
        // resolution.
        final LdapName groupDistinguishedNamePrefix;
        try
        {
            groupDistinguishedNamePrefix = fixedLdapName(this.groupSearchBase.toLowerCase());
        }
        catch (InvalidNameException e)
        {
            Object[] params = {this.groupSearchBase.toLowerCase(), e.getLocalizedMessage()};
            throw new AlfrescoRuntimeException("synchronization.err.ldap.search.base.invalid", params, e);
        }
        final LdapName userDistinguishedNamePrefix;
        try
        {
            userDistinguishedNamePrefix = fixedLdapName(this.userSearchBase.toLowerCase());
        }
        catch (InvalidNameException e)
        {
            Object[] params = {this.userSearchBase.toLowerCase(), e.getLocalizedMessage()};
            throw new AlfrescoRuntimeException("synchronization.err.ldap.search.base.invalid", params, e);
        }

        final boolean disjoint = !groupDistinguishedNamePrefix.startsWith(userDistinguishedNamePrefix)
                && !userDistinguishedNamePrefix.startsWith(groupDistinguishedNamePrefix);

        // Choose / generate the query
        String query;
        if (modifiedSince == null)
        {
            query = this.groupQuery;
        }
        else
        {
            query = MessageFormat.format(this.groupDifferentialQuery, this.timestampFormat.format(modifiedSince));
        }

        // Run the query and process the results
        final Map<String, NodeDescription> lookup = new TreeMap<String, NodeDescription>();
        processQuery(new AbstractSearchCallback() {
            // We get a whole new context to avoid interference with cookies from paged results
            private DirContext ctx = LDAPUserRegistry.this.ldapInitialContextFactory.getDefaultIntialDirContext();

            protected void doProcess(SearchResult result) throws NamingException, ParseException
            {
                Attributes attributes = result.getAttributes();
                Attribute gidAttribute = attributes.get(LDAPUserRegistry.this.groupIdAttributeName);
                if (gidAttribute == null)
                {
                    if (LDAPUserRegistry.this.errorOnMissingGID)
                    {
                        Object[] params = {result.getNameInNamespace(), LDAPUserRegistry.this.groupIdAttributeName};
                        throw new AlfrescoRuntimeException("synchronization.err.ldap.get.group.id.missing", params);
                    }
                    else
                    {
                        LDAPUserRegistry.logger.warn("Missing GID on " + attributes);
                        return;
                    }
                }
                String groupShortName = gidAttribute.get(0).toString();
                String gid = "GROUP_" + groupShortName;

                NodeDescription group = lookup.get(gid);
                if (group == null)
                {
                    // Apply the mapped properties to the node description
                    group = mapToNode(LDAPUserRegistry.this.groupAttributeMapping,
                            LDAPUserRegistry.this.groupAttributeDefaults, result);

                    // Make sure the "GROUP_" prefix is applied
                    group.getProperties().put(ContentModel.PROP_AUTHORITY_NAME, gid);
                    lookup.put(gid, group);
                }
                else if (LDAPUserRegistry.this.errorOnDuplicateGID)
                {
                    throw new AlfrescoRuntimeException("Duplicate group id found for " + gid);
                }
                else
                {
                    LDAPUserRegistry.logger.warn("Duplicate gid found for " + gid + " -> merging definitions");
                }

                Set<String> childAssocs = group.getChildAssociations();

                // Get the repeating (and possibly range restricted) member attribute
                Attribute memAttribute = getRangeRestrictedAttribute(attributes,
                        LDAPUserRegistry.this.memberAttributeName);
                int nextStart = LDAPUserRegistry.this.attributeBatchSize;
                if (LDAPUserRegistry.logger.isDebugEnabled())
                {
                    LDAPUserRegistry.logger.debug("Processing group: " + gid +
                            ", from source: " + group.getSourceId());
                }
                // Loop until we get to the end of the range
                while (memAttribute != null)
                {
                    for (int i = 0; i < memAttribute.size(); i++)
                    {
                        String attribute = (String) memAttribute.get(i);
                        if (attribute != null && attribute.length() > 0)
                        {
                            try
                            {
                                // Attempt to parse the member attribute as a DN. If this fails we have a fallback
                                // in the catch block
                                LdapName distinguishedNameForComparison = fixedLdapName(attribute.toLowerCase());
                                Attribute nameAttribute;

                                // If the user and group search bases are different we may be able to recognize user
                                // and group DNs without a secondary lookup
                                if (disjoint)
                                {
                                    LdapName distinguishedName = fixedLdapName(attribute);
                                    Attributes nameAttributes = distinguishedName.getRdn(distinguishedName.size() - 1)
                                            .toAttributes();

                                    // Recognize user DNs
                                    if (distinguishedNameForComparison.startsWith(userDistinguishedNamePrefix)
                                            && (nameAttribute = nameAttributes
                                                    .get(LDAPUserRegistry.this.userIdAttributeName)) != null)
                                    {
                                        if (LDAPUserRegistry.logger.isDebugEnabled())
                                        {
                                            LDAPUserRegistry.logger.debug("User DN recognized: " + nameAttribute.get());
                                        }
                                        childAssocs.add((String) nameAttribute.get());
                                        continue;
                                    }

                                    // Recognize group DNs
                                    if (distinguishedNameForComparison.startsWith(groupDistinguishedNamePrefix)
                                            && (nameAttribute = nameAttributes
                                                    .get(LDAPUserRegistry.this.groupIdAttributeName)) != null)
                                    {
                                        if (LDAPUserRegistry.logger.isDebugEnabled())
                                        {
                                            LDAPUserRegistry.logger.debug("Group DN recognized: " + "GROUP_" + nameAttribute.get());
                                        }
                                        childAssocs.add("GROUP_" + nameAttribute.get());
                                        continue;
                                    }
                                }

                                // If we can't determine the name and type from the DN alone, try a directory lookup
                                if (distinguishedNameForComparison.startsWith(userDistinguishedNamePrefix)
                                        || distinguishedNameForComparison.startsWith(groupDistinguishedNamePrefix))
                                {
                                    try
                                    {
                                        Attributes childAttributes = this.ctx.getAttributes(jndiName(attribute),
                                                new String[]{
                                                        "objectclass", LDAPUserRegistry.this.groupIdAttributeName,
                                                        LDAPUserRegistry.this.userIdAttributeName
                                                });
                                        Attribute objectClass = childAttributes.get("objectclass");
                                        if (hasAttributeValue(objectClass, LDAPUserRegistry.this.personType))
                                        {
                                            nameAttribute = childAttributes
                                                    .get(LDAPUserRegistry.this.userIdAttributeName);
                                            if (nameAttribute == null)
                                            {
                                                if (LDAPUserRegistry.this.errorOnMissingUID)
                                                {
                                                    throw new AlfrescoRuntimeException(
                                                            "User missing user id attribute DN =" + attribute
                                                                    + "  att = "
                                                                    + LDAPUserRegistry.this.userIdAttributeName);
                                                }
                                                else
                                                {
                                                    LDAPUserRegistry.logger.warn("User missing user id attribute DN ="
                                                            + attribute + "  att = "
                                                            + LDAPUserRegistry.this.userIdAttributeName);
                                                    continue;
                                                }
                                            }
                                            if (LDAPUserRegistry.logger.isDebugEnabled())
                                            {
                                                LDAPUserRegistry.logger.debug("User DN recognized by directory lookup: " + nameAttribute.get());
                                            }
                                            childAssocs.add((String) nameAttribute.get());
                                            continue;
                                        }
                                        else if (hasAttributeValue(objectClass, LDAPUserRegistry.this.groupType))
                                        {
                                            nameAttribute = childAttributes
                                                    .get(LDAPUserRegistry.this.groupIdAttributeName);
                                            if (nameAttribute == null)
                                            {
                                                if (LDAPUserRegistry.this.errorOnMissingGID)
                                                {
                                                    Object[] params = {result.getNameInNamespace(), LDAPUserRegistry.this.groupIdAttributeName};
                                                    throw new AlfrescoRuntimeException("synchronization.err.ldap.get.group.id.missing", params);
                                                }
                                                else
                                                {
                                                    LDAPUserRegistry.logger.warn("Missing GID on " + childAttributes);
                                                    continue;
                                                }
                                            }
                                            if (LDAPUserRegistry.logger.isDebugEnabled())
                                            {
                                                LDAPUserRegistry.logger.debug("Group DN recognized by directory lookup: " + "GROUP_" + nameAttribute.get());
                                            }
                                            childAssocs.add("GROUP_" + nameAttribute.get());
                                            continue;
                                        }
                                    }
                                    catch (ServiceUnavailableException | CommunicationException e)
                                    {
                                        // MNT-21614: Check & fail if communication breaks due to ServiceUnavailableException or CommunicationException
                                        if (e.getMessage() != null)
                                        {
                                            Object[] params = {e.getLocalizedMessage()};
                                            throw new AlfrescoRuntimeException("synchronization.err.ldap.search", params, e);
                                        }
                                        continue;
                                    }
                                    catch (NamingException e)
                                    {
                                        // Check if it is a timeout and fail
                                        // MNT-17966
                                        if (e.getMessage() != null && e.getMessage().startsWith(NAMING_TIMEOUT_EXCEPTION_MESSAGE))
                                        {
                                            Object[] params = {e.getLocalizedMessage()};
                                            throw new AlfrescoRuntimeException("synchronization.err.ldap.search", params, e);
                                        }
                                        // Unresolvable name
                                        if (LDAPUserRegistry.this.errorOnMissingMembers)
                                        {
                                            Object[] params = {groupShortName, attribute, e.getLocalizedMessage()};
                                            throw new AlfrescoRuntimeException("synchronization.err.ldap.group.member.missing.exception", params, e);
                                        }
                                        LDAPUserRegistry.logger.warn("Failed to resolve member of group '"
                                                + groupShortName + "' with distinguished name: " + attribute, e);
                                        continue;
                                    }
                                }
                                if (LDAPUserRegistry.this.errorOnMissingMembers)
                                {
                                    Object[] params = {groupShortName, attribute};
                                    throw new AlfrescoRuntimeException("synchronization.err.ldap.group.member.missing", params);
                                }
                                LDAPUserRegistry.logger.warn("Failed to resolve member of group '" + groupShortName
                                        + "' with distinguished name: " + attribute);
                            }
                            catch (InvalidNameException e)
                            {
                                // The member attribute didn't parse as a DN. So assume we have a group class like
                                // posixGroup (FDS) that directly lists user names
                                if (LDAPUserRegistry.logger.isDebugEnabled())
                                {
                                    LDAPUserRegistry.logger.debug("Member DN recognized as posixGroup: " + attribute);
                                }
                                childAssocs.add(attribute);
                            }
                        }
                    }

                    // If we are using attribute matching and we haven't got to the end (indicated by an asterisk),
                    // fetch the next batch
                    if (nextStart > 0
                            && !LDAPUserRegistry.PATTERN_RANGE_END.matcher(memAttribute.getID().toLowerCase()).find())
                    {
                        Attributes childAttributes = this.ctx.getAttributes(jndiName(result.getNameInNamespace()),
                                new String[]{
                                        LDAPUserRegistry.this.memberAttributeName + ";range=" + nextStart + '-'
                                                + (nextStart + LDAPUserRegistry.this.attributeBatchSize - 1)
                                });
                        memAttribute = getRangeRestrictedAttribute(childAttributes,
                                LDAPUserRegistry.this.memberAttributeName);
                        nextStart += LDAPUserRegistry.this.attributeBatchSize;
                    }
                    else
                    {
                        memAttribute = null;
                    }
                }
            }

            public void close() throws NamingException
            {
                this.ctx.close();
            }
        }, this.groupSearchBase, query, this.groupKeys.getFirst());

        if (LDAPUserRegistry.logger.isDebugEnabled())
        {
            LDAPUserRegistry.logger.debug("Found " + lookup.size());
        }

        return lookup.values();
    }

    /* (non-Javadoc)
     * 
     * @see org.alfresco.repo.security.sync.ldap.LDAPNameResolver#resolveDistinguishedName(java.lang.String) */
    public String resolveDistinguishedName(String userId, AuthenticationDiagnostic diagnostic) throws AuthenticationException
    {
        if (logger.isDebugEnabled())
        {
            logger.debug("resolveDistinguishedName userId:" + userId);
        }
        SearchControls userSearchCtls = new SearchControls();
        userSearchCtls.setSearchScope(SearchControls.SUBTREE_SCOPE);

        // Although we don't actually need any attributes, we ask for the UID for compatibility with Sun Directory Server. See ALF-3868
        userSearchCtls.setReturningAttributes(new String[]{
                this.userIdAttributeName
        });

        String query = this.userSearchBase + "(&" + this.personQuery
                + "(" + this.userIdAttributeName + "= userId))";

        NamingEnumeration<SearchResult> searchResults = null;
        SearchResult result = null;

        InitialDirContext ctx = null;
        try
        {
            ctx = this.ldapInitialContextFactory.getDefaultIntialDirContext(diagnostic);

            // Execute the user query with an additional condition that ensures only the user with the required ID is
            // returned. Force RFC 2254 escaping of the user ID in the filter to avoid any manipulation

            searchResults = ctx.search(this.userSearchBase, "(&" + this.personQuery
                    + "(" + this.userIdAttributeName + "={0}))",
                    new Object[]{
                            userId
                    }, userSearchCtls);

            if (searchResults.hasMore())
            {
                result = searchResults.next();
                Attributes attributes = result.getAttributes();
                Attribute uidAttribute = attributes.get(this.userIdAttributeName);
                if (uidAttribute == null)
                {
                    if (this.errorOnMissingUID)
                    {
                        throw new AlfrescoRuntimeException(
                                "User returned by user search does not have mandatory user id attribute "
                                        + attributes);
                    }
                    else
                    {
                        LDAPUserRegistry.logger
                                .warn("User returned by user search does not have mandatory user id attribute "
                                        + attributes);
                    }
                }
                // MNT:2597 We don't trust the LDAP server's treatment of whitespace, accented characters etc. We will
                // only resolve this user if the user ID matches
                else if (userId.equalsIgnoreCase((String) uidAttribute.get(0)))
                {
                    String name = result.getNameInNamespace();

                    // Close the contexts, see ALF-20682
                    Context context = (Context) result.getObject();
                    if (context != null)
                    {
                        context.close();
                    }
                    result = null;
                    return name;
                }

                // Close the contexts, see ALF-20682
                Context context = (Context) result.getObject();
                if (context != null)
                {
                    context.close();
                }
                result = null;
            }

            Object[] args = {userId, query};
            diagnostic.addStep(AuthenticationDiagnostic.STEP_KEY_LDAP_LOOKUP_USER, false, args);

            throw new AuthenticationException("authentication.err.connection.ldap.user.notfound", args, diagnostic);
        }
        catch (NamingException e)
        {
            // Connection is good here - AuthenticationException would be thrown by ldapInitialContextFactory

            Object[] args1 = {userId, query};
            diagnostic.addStep(AuthenticationDiagnostic.STEP_KEY_LDAP_SEARCH, false, args1);

            // failed to search
            Object[] args = {e.getLocalizedMessage()};
            throw new AuthenticationException("authentication.err.connection.ldap.search", diagnostic, args, e);
        }
        finally
        {
            if (result != null)
            {
                try
                {
                    Context context = (Context) result.getObject();
                    if (context != null)
                    {
                        context.close();
                    }
                }
                catch (Exception e)
                {
                    logger.debug("error when closing result block context", e);
                }
            }
            if (searchResults != null)
            {
                try
                {
                    searchResults.close();
                }
                catch (Exception e)
                {
                    logger.debug("error when closing searchResults context", e);
                }
            }
            if (ctx != null)
            {
                try
                {
                    ctx.close();
                }
                catch (NamingException e)
                {
                    logger.debug("error when closing ldap context", e);
                }
            }
        }
    }

    private Pair<String[], Set<QName>> initKeys(Map<String, String> attributeMapping,
            String... extraAttibutes)
    {
        // Compile a complete array of LDAP attribute names, including operational attributes
        Set<String> attributeSet = new TreeSet<String>();
        attributeSet.addAll(Arrays.asList(extraAttibutes));
        attributeSet.add(this.modifyTimestampAttributeName);
        for (String attribute : attributeMapping.values())
        {
            if (attribute != null)
            {
                attributeSet.add(attribute);
            }
        }
        String[] attributeNames = new String[attributeSet.size()];
        attributeSet.toArray(attributeNames);

        // Create a set with the property names converted to QNames
        Set<QName> qnames = new HashSet<QName>(attributeMapping.size() * 2);
        for (String property : attributeMapping.keySet())
        {
            qnames.add(QName.createQName(property, this.namespaceService));
        }

        return new Pair<String[], Set<QName>>(attributeNames, qnames);
    }

    private NodeDescription mapToNode(Map<String, String> attributeMapping, Map<String, String> attributeDefaults,
            SearchResult result) throws NamingException
    {
        NodeDescription nodeDescription = new NodeDescription(result.getNameInNamespace());
        Attributes ldapAttributes = result.getAttributes();

        // Parse the timestamp
        Attribute modifyTimestamp = ldapAttributes.get(this.modifyTimestampAttributeName);
        if (modifyTimestamp != null)
        {
            try
            {
                nodeDescription.setLastModified(this.timestampFormat.parse(modifyTimestamp.get().toString()));
            }
            catch (ParseException e)
            {
                throw new AlfrescoRuntimeException("Failed to parse timestamp.", e);
            }
        }

        // Apply the mapped attributes
        PropertyMap properties = nodeDescription.getProperties();
        for (String key : attributeMapping.keySet())
        {
            QName keyQName = QName.createQName(key, this.namespaceService);

            // cater for null
            String attributeName = attributeMapping.get(key);
            if (attributeName != null)
            {
                Attribute attribute = ldapAttributes.get(attributeName);
                String defaultAttribute = attributeDefaults.get(key);

                if (attribute != null)
                {
                    String value = (String) attribute.get(0);
                    if (value != null)
                    {
                        properties.put(keyQName, value);
                    }
                }
                else if (defaultAttribute != null)
                {
                    properties.put(keyQName, defaultAttribute);
                }
                else
                {
                    // Make sure that a 2nd sync, updates deleted ldap attributes(MNT-14026)
                    properties.put(keyQName, null);
                }
            }
            else
            {
                String defaultValue = attributeDefaults.get(key);
                if (defaultValue != null)
                {
                    properties.put(keyQName, defaultValue);
                }
            }
        }
        return nodeDescription;
    }

    /**
     * Converts a given DN into one suitable for use through JNDI. In particular, escapes special characters such as '/' which have special meaning to JNDI.
     * 
     * @param dn
     *            the dn
     * @return the name
     * @throws InvalidNameException
     *             the invalid name exception
     */
    public static Name jndiName(String dn) throws InvalidNameException
    {
        Name n = new CompositeName();
        n.add(dn);
        return n;
    }

    /**
     * Works around a bug in the JDK DN parsing. If an RDN has trailing escaped whitespace in the format "\\20" then LdapName would normally strip this. This method works around this by replacing "\\20" with "\\ " and "\\0D" with "\\\r".
     * 
     * @param dn
     *            the DN
     * @return the parsed ldap name
     * @throws InvalidNameException
     *             if the DN is invalid
     */
    private static LdapName fixedLdapName(String dn) throws InvalidNameException
    {
        // Optimization for DNs without escapes in them
        if (dn.indexOf('\\') == -1)
        {
            return new LdapName(dn);
        }

        StringBuilder fixed = new StringBuilder(dn.length());
        int length = dn.length();
        for (int i = 0; i < length; i++)
        {
            char c = dn.charAt(i);
            char c1, c2;
            if (c == '\\')
            {
                if (i + 2 < length && Character.isLetterOrDigit(c1 = dn.charAt(i + 1))
                        && Character.isLetterOrDigit(c2 = dn.charAt(i + 2)))
                {
                    if (c1 == '2' && c2 == '0')
                    {
                        fixed.append("\\ ");
                    }
                    else if (c1 == '0' && c2 == 'D')
                    {
                        fixed.append("\\\r");
                    }
                    else
                    {
                        fixed.append(dn, i, i + 3);
                    }
                    i += 2;
                }
                else if (i + 1 < length)
                {
                    fixed.append(dn, i, i + 2);
                    i += 1;
                }
                else
                {
                    fixed.append(c);
                }
            }
            else
            {
                fixed.append(c);
            }
        }
        return new LdapName(fixed.toString());
    }

    /**
     * Invokes the given callback on each entry returned by the given query.
     * 
     * @param callback
     *            the callback
     * @param searchBase
     *            the base DN for the search
     * @param query
     *            the query
     * @param returningAttributes
     *            the attributes to include in search results
     * @throws AlfrescoRuntimeException
     */
    private void processQuery(SearchCallback callback, String searchBase, String query, String[] returningAttributes)
    {
        SearchControls searchControls = new SearchControls();
        searchControls.setSearchScope(SearchControls.SUBTREE_SCOPE);
        searchControls.setReturningAttributes(returningAttributes);
        if (LDAPUserRegistry.logger.isDebugEnabled())
        {
            LDAPUserRegistry.logger.debug("Processing query");
            LDAPUserRegistry.logger.debug("Search base: " + searchBase);
            LDAPUserRegistry.logger.debug("    Return result limit: " + searchControls.getCountLimit());
            LDAPUserRegistry.logger.debug("    DerefLink: " + searchControls.getDerefLinkFlag());
            LDAPUserRegistry.logger.debug("    Return named object: " + searchControls.getReturningObjFlag());
            LDAPUserRegistry.logger.debug("    Time limit for search: " + searchControls.getTimeLimit());
            LDAPUserRegistry.logger.debug("    Attributes to return: " + returningAttributes.length + " items.");
            for (String ra : returningAttributes)
            {
                LDAPUserRegistry.logger.debug("        Attribute: " + ra);
            }
        }
        InitialDirContext ctx = null;
        NamingEnumeration<SearchResult> searchResults = null;
        SearchResult result = null;
        try
        {
            ctx = this.ldapInitialContextFactory.getDefaultIntialDirContext(this.queryBatchSize);
            do
            {
                searchResults = ctx.search(searchBase, query, searchControls);

                while (searchResults.hasMore())
                {
                    result = searchResults.next();
                    callback.process(result);

                    // Close the contexts, see ALF-20682
                    Context resultCtx = (Context) result.getObject();
                    if (resultCtx != null)
                    {
                        resultCtx.close();
                    }
                    result = null;
                }
            } while (this.ldapInitialContextFactory.hasNextPage(ctx, this.queryBatchSize));
        }
        catch (NamingException e)
        {
            Object[] params = {e.getLocalizedMessage()};
            throw new AlfrescoRuntimeException("synchronization.err.ldap.search", params, e);
        }
        catch (ParseException e)
        {
            Object[] params = {e.getLocalizedMessage()};
            throw new AlfrescoRuntimeException("synchronization.err.ldap.search", params, e);
        }
        finally
        {
            if (result != null)
            {
                try
                {
                    Context resultCtx = (Context) result.getObject();
                    if (resultCtx != null)
                    {
                        resultCtx.close();
                    }
                }
                catch (Exception e)
                {
                    logger.debug("error when closing result block context", e);
                }
            }
            if (searchResults != null)
            {
                try
                {
                    searchResults.close();
                }
                catch (Exception e)
                {
                    logger.debug("error when closing searchResults context", e);
                }
                searchResults = null;
            }
            if (ctx != null)
            {
                try
                {
                    ctx.close();
                }
                catch (NamingException e)
                {}
            }
            try
            {
                callback.close();
            }
            catch (NamingException e)
            {}
        }
    }

    /**
     * Does a case-insensitive search for the given value in an attribute.
     * 
     * @param attribute
     *            the attribute
     * @param value
     *            the value to search for
     * @return <code>true</code>, if the value was found
     * @throws NamingException
     *             if there is a problem accessing the attribute values
     */
    private boolean hasAttributeValue(Attribute attribute, String value) throws NamingException
    {
        if (attribute != null)
        {
            NamingEnumeration<?> values = attribute.getAll();
            while (values.hasMore())
            {
                try
                {
                    if (value.equalsIgnoreCase((String) values.next()))
                    {
                        return true;
                    }
                }
                catch (ClassCastException e)
                {
                    // Not a string value. ignore and continue
                }
            }
        }
        return false;
    }

    /**
     * Gets the values of a repeating attribute that may have range restriction options. If an attribute is range restricted, it will appear in the attribute set with a ";range=i-j" option, where i and j indicate the start and end index, and j is '*' if it is at the end.
     * 
     * @param attributes
     *            the attributes
     * @param attributeName
     *            the attribute name
     * @return the range restricted attribute
     * @throws NamingException
     *             the naming exception
     */
    private Attribute getRangeRestrictedAttribute(Attributes attributes, String attributeName) throws NamingException
    {
        Attribute unrestricted = attributes.get(attributeName);
        if (unrestricted != null)
        {
            return unrestricted;
        }
        NamingEnumeration<? extends Attribute> i = attributes.getAll();
        String searchString = attributeName.toLowerCase() + ';';
        while (i.hasMore())
        {
            Attribute attribute = i.next();
            if (attribute.getID().toLowerCase().startsWith(searchString))
            {
                return attribute;
            }
        }
        return null;
    }

    /**
     * Wraps the LDAP user query as a virtual {@link Collection}.
     */
    public class PersonCollection extends AbstractCollection<NodeDescription>
    {

        /** The query. */
        private String query;

        /** The total estimated size. */
        private int totalEstimatedSize;

        /**
         * Instantiates a new person collection.
         * 
         * @param modifiedSince
         *            if non-null, then only descriptions of users modified since this date should be returned; if <code>null</code> then descriptions of all users should be returned.
         */
        public PersonCollection(Date modifiedSince)
        {
            // Choose / generate the appropriate query
            if (modifiedSince == null)
            {
                this.query = LDAPUserRegistry.this.personQuery;
            }
            else
            {
                this.query = MessageFormat.format(LDAPUserRegistry.this.personDifferentialQuery,
                        LDAPUserRegistry.this.timestampFormat.format(modifiedSince));
            }

            // Estimate the size of this collection by running the entire query once, if progress
            // estimation is enabled
            if (LDAPUserRegistry.this.enableProgressEstimation)
            {
                class CountingCallback extends AbstractSearchCallback
                {
                    int count;

                    /* (non-Javadoc)
                     * 
                     * @see org.alfresco.repo.security.sync.ldap.LDAPUserRegistry.SearchCallback#process(javax.naming.directory .SearchResult) */
                    protected void doProcess(SearchResult result) throws NamingException, ParseException
                    {
                        this.count++;
                        if (LDAPUserRegistry.logger.isDebugEnabled())
                        {
                            String personName = result.getNameInNamespace();
                            LDAPUserRegistry.logger.debug("Processing person: " + personName);
                        }
                    }

                    /* (non-Javadoc)
                     * 
                     * @see org.alfresco.repo.security.sync.ldap.LDAPUserRegistry.SearchCallback#close() */
                    public void close() throws NamingException
                    {}

                }
                CountingCallback countingCallback = new CountingCallback();
                processQuery(countingCallback, LDAPUserRegistry.this.userSearchBase, this.query, new String[]{});
                this.totalEstimatedSize = countingCallback.count;
            }
            else
            {
                this.totalEstimatedSize = -1;
            }
        }

        /* (non-Javadoc)
         * 
         * @see java.util.AbstractCollection#iterator() */
        @Override
        public Iterator<NodeDescription> iterator()
        {
            return new PersonIterator();
        }

        /* (non-Javadoc)
         * 
         * @see java.util.AbstractCollection#size() */
        @Override
        public int size()
        {
            return this.totalEstimatedSize;
        }

        /**
         * An iterator over the person collection. Wraps the LDAP query in 'real time'.
         */
        private class PersonIterator implements Iterator<NodeDescription>
        {

            /** The directory context. */
            private InitialDirContext ctx;

            /** The user search controls. */
            private SearchControls userSearchCtls;

            /** The search results. */
            private NamingEnumeration<SearchResult> searchResults;

            /** The uids. */
            private HashSet<String> uids = new HashSet<String>();

            /** The next node description to return. */
            private NodeDescription next;

            /** Paged result response control retrieved from ldap server */
            private PagedResultsResponseControl pagedResultsResponseControl;

            /** Stores last processed person uid */
            private String lastProcessedPerson;

            /** Indicates that sync process has been retried and we still don't reach last processed person */
            private boolean skipToLastProcessedPerson;

            /**
             * Instantiates a new person iterator.
             */
            public PersonIterator()
            {
                try
                {

                    this.ctx = LDAPUserRegistry.this.ldapInitialContextFactory
                            .getDefaultIntialDirContext(LDAPUserRegistry.this.queryBatchSize);

                    this.userSearchCtls = new SearchControls();
                    this.userSearchCtls.setSearchScope(SearchControls.SUBTREE_SCOPE);
                    this.userSearchCtls.setReturningAttributes(LDAPUserRegistry.this.userKeys.getFirst());

                    this.next = fetchNext();
                }
                catch (NamingException e)
                {
                    throw new AlfrescoRuntimeException("Failed to import people.", e);
                }
                finally
                {
                    if (this.searchResults == null)
                    {
                        try
                        {
                            this.ctx.close();
                        }
                        catch (Exception e)
                        {}
                        this.ctx = null;
                    }
                }
            }

            /* (non-Javadoc)
             * 
             * @see java.util.Iterator#hasNext() */
            public boolean hasNext()
            {
                return this.next != null;
            }

            /* (non-Javadoc)
             * 
             * @see java.util.Iterator#next() */
            public NodeDescription next()
            {
                if (this.next == null)
                {
                    throw new IllegalStateException();
                }
                NodeDescription current = this.next;
                try
                {
                    this.next = fetchNext();
                }
                catch (CommunicationException e)
                {
                    try
                    {
                        if (LDAPUserRegistry.logger.isDebugEnabled())
                        {
                            LDAPUserRegistry.logger.debug("CommunicationException was thrown with message: " + e.getMessage() + ". "
                                    + "Performing another attempt to sync with ldap. Last processed person was: '"
                                    + (this.lastProcessedPerson == null ? "null" : this.lastProcessedPerson) + "'");
                        }

                        this.ctx = LDAPUserRegistry.this.ldapInitialContextFactory
                                .getDefaultIntialDirContext(LDAPUserRegistry.this.queryBatchSize);

                        if (LDAPUserRegistry.this.queryBatchSize > 0)
                        {
                            ((LdapContext) this.ctx).setRequestControls(new Control[]{
                                    new PagedResultsControl(LDAPUserRegistry.this.queryBatchSize, pagedResultsResponseControl == null ? null : pagedResultsResponseControl.getCookie(), Control.CRITICAL)
                            });
                        }

                        // make sure we will skip already processed persons
                        this.skipToLastProcessedPerson = true;
                        // release previous search results
                        this.searchResults.close();
                        this.searchResults = null;
                        // move position to next element
                        this.next = fetchNext();
                    }
                    catch (Exception ex)
                    {
                        throw new AlfrescoRuntimeException("Failed to import people. Also failed to restart sync process.", ex);
                    }
                }
                catch (NamingException e)
                {
                    throw new AlfrescoRuntimeException("Failed to import people.", e);
                }
                return current;
            }

            /**
             * Pre-fetches the next node description to be returned.
             * 
             * @return the node description
             * @throws NamingException
             *             on a naming exception
             */
            private NodeDescription fetchNext() throws NamingException
            {
                boolean readyForNextPage;
                do
                {
                    readyForNextPage = this.searchResults == null;
                    while (!readyForNextPage && this.searchResults.hasMore())
                    {
                        SearchResult result = this.searchResults.next();
                        Attributes attributes = result.getAttributes();
                        Attribute uidAttribute = attributes.get(LDAPUserRegistry.this.userIdAttributeName);
                        if (uidAttribute == null)
                        {
                            if (LDAPUserRegistry.this.errorOnMissingUID)
                            {
                                Object[] params = {result.getNameInNamespace(), LDAPUserRegistry.this.userIdAttributeName};
                                throw new AlfrescoRuntimeException("synchronization.err.ldap.get.user.id.missing", params);
                            }
                            else
                            {
                                LDAPUserRegistry.logger
                                        .warn("User returned by user search does not have mandatory user id attribute "
                                                + attributes);
                                continue;
                            }
                        }
                        String uid = (String) uidAttribute.get(0);

                        if (!this.skipToLastProcessedPerson)
                        {
                            // MNT-14001 fix, remember last processed person
                            // this will serve as indicator where we should restart sync in case if sync retry occurs
                            this.lastProcessedPerson = uid;
                        }

                        if (this.uids.contains(uid))
                        {
                            if (this.skipToLastProcessedPerson)
                            {
                                LDAPUserRegistry.logger
                                        .info("Skipping already synchronized person during sync retry - "
                                                + uid);

                                if (uid.equals(this.lastProcessedPerson))
                                {
                                    // MNT-14001 fix, it looks like we already reached last processed person
                                    this.skipToLastProcessedPerson = false;
                                }
                                continue;
                            }
                            else
                            {
                                LDAPUserRegistry.logger
                                        .warn("Duplicate uid found - there will be more than one person object for this user - "
                                                + uid);
                            }
                        }

                        this.uids.add(uid);

                        if (LDAPUserRegistry.logger.isDebugEnabled())
                        {
                            LDAPUserRegistry.logger.debug("Adding user for " + uid);
                        }

                        // Apply the mapped properties to the node description
                        NodeDescription nodeDescription = mapToNode(LDAPUserRegistry.this.personAttributeMapping,
                                LDAPUserRegistry.this.personAttributeDefaults, result);

                        Object obj = result.getObject();
                        if (obj != null && obj instanceof Context)
                        {
                            ((Context) obj).close();
                            obj = null;
                        }
                        result = null;

                        return nodeDescription;
                    }

                    // Examine the paged results control response for an indication that another page is available
                    if (!readyForNextPage)
                    {
                        readyForNextPage = LDAPUserRegistry.this.ldapInitialContextFactory.hasNextPage(this.ctx,
                                LDAPUserRegistry.this.queryBatchSize);

                        if (readyForNextPage)
                        {
                            // MNT-14001 fix, next page available - remember last paged results control
                            // using cookie from this control we can restart search from current position if needed
                            LdapContext ldapContext = (LdapContext) this.ctx;
                            Control[] controls = ldapContext.getResponseControls();

                            if (controls != null)
                            {
                                for (Control control : controls)
                                {
                                    if (control instanceof PagedResultsResponseControl)
                                    {
                                        this.pagedResultsResponseControl = (PagedResultsResponseControl) control;
                                    }
                                }
                            }
                        }
                    }

                    // Fetch the next page if there is one
                    if (readyForNextPage)
                    {
                        this.searchResults = this.ctx.search(LDAPUserRegistry.this.userSearchBase,
                                PersonCollection.this.query, this.userSearchCtls);
                    }
                } while (readyForNextPage);
                this.searchResults.close();
                this.searchResults = null;
                this.ctx.close();
                this.ctx = null;
                return null;
            }

            /* (non-Javadoc)
             * 
             * @see java.util.Iterator#remove() */
            public void remove()
            {
                throw new UnsupportedOperationException();
            }
        };
    }

    /**
     * An interface for callbacks passed to the {@link LDAPUserRegistry#processQuery(SearchCallback, String, String, String[])} method.
     */
    protected static interface SearchCallback
    {

        /**
         * Processes the given search result.
         * 
         * @param result
         *            the result
         * @throws NamingException
         *             on naming exceptions
         * @throws ParseException
         *             on parse exceptions
         */
        public void process(SearchResult result) throws NamingException, ParseException;

        /**
         * Release any resources held by the callback.
         * 
         * @throws NamingException
         *             the naming exception
         */
        public void close() throws NamingException;
    }

    /**
     * An abstract implementation of SearchCallback interface. Responsible for correct release of SearchResult resource.
     */
    protected abstract static class AbstractSearchCallback implements SearchCallback
    {
        @Override
        public void process(SearchResult result) throws NamingException, ParseException
        {
            try
            {
                doProcess(result);
            }
            finally
            {
                Object obj = result.getObject();

                if (obj != null && obj instanceof Context)
                {
                    try
                    {
                        ((Context) obj).close();
                    }
                    catch (NamingException e)
                    {
                        logger.debug("error when closing result block context", e);
                    }
                    obj = null;
                }
                result = null;
            }
        }

        protected abstract void doProcess(SearchResult result) throws NamingException, ParseException;
    }
}
